"""Property-based tests for POI detection functionality.

Feature: buyu-walking-app
Testing correctness properties related to POI detection and deduplication.
"""
import pytest
from hypothesis import given, strategies as st, settings, assume, HealthCheck
from unittest.mock import Mock, patch
from app.models.location import Location
from app.models.poi import POI
from app.models.walking_session import WalkingSession
from app.services.poi_detector_service import POIDetectorService


# Strategies for generating test data
@st.composite
def locations(draw):
    """Generate valid location data."""
    return Location(
        latitude=draw(st.floats(min_value=-90, max_value=90)),
        longitude=draw(st.floats(min_value=-180, max_value=180)),
        altitude=draw(st.one_of(st.none(), st.floats(min_value=-500, max_value=9000))),
        accuracy=draw(st.floats(min_value=0.1, max_value=100))
    )


@st.composite
def poi_with_location(draw, base_location=None):
    """Generate a POI with a location near the base location."""
    if base_location is None:
        lat = draw(st.floats(min_value=-90, max_value=90))
        lon = draw(st.floats(min_value=-180, max_value=180))
    else:
        # Generate POI within reasonable distance of base location
        # Using small offsets to ensure we can test detection range
        lat_offset = draw(st.floats(min_value=-0.001, max_value=0.001))
        lon_offset = draw(st.floats(min_value=-0.001, max_value=0.001))
        lat = base_location.latitude + lat_offset
        lon = base_location.longitude + lon_offset
        # Ensure valid range
        lat = max(-90, min(90, lat))
        lon = max(-180, min(180, lon))
    
    poi = Mock(spec=POI)
    poi.id = draw(st.text(min_size=1, max_size=36, alphabet=st.characters(whitelist_categories=('Lu', 'Ll', 'Nd'))))
    poi.name = draw(st.text(min_size=1, max_size=50, alphabet=st.characters(whitelist_categories=('Lu', 'Ll', 'Nd', 'Zs'))))
    poi.latitude = lat
    poi.longitude = lon
    poi.category = draw(st.sampled_from(['风景名胜', '公园广场', '文化场馆', '博物馆', '纪念馆', '其他']))
    poi.description = draw(st.text(max_size=200))
    poi.photos = []
    poi.rating = draw(st.one_of(st.none(), st.floats(min_value=0, max_value=5)))
    poi.tags = []
    
    return poi


class TestPOIDetectionTriggerProperty:
    """Property-based tests for POI detection trigger."""
    
    @settings(max_examples=100, suppress_health_check=[HealthCheck.function_scoped_fixture])
    @given(
        user_location=locations(),
        poi=poi_with_location()
    )
    def test_property_9_poi_detection_trigger_within_range(self, app, user_location, poi):
        """
        Feature: buyu-walking-app, Property 9: 景点检测触发
        Validates: Requirements 2.2
        
        Property: For any user location and POI, when the distance between them
        is less than the detection range threshold (30 meters), the system should
        trigger the POI introduction flow.
        
        This tests that:
        1. POIs within 30m are detected
        2. POIs outside 30m are not detected
        3. Detection works for any valid location pair
        """
        with app.app_context():
            # Arrange: Create a session
            session = WalkingSession()
            session.id = 'test_session_id'
            session.user_id = 'test_user_id'
            session.status = 'active'
            session.visited_poi_ids = []
            
            # Calculate actual distance between user and POI
            service = POIDetectorService()
            poi_location = Location(
                latitude=poi.latitude,
                longitude=poi.longitude,
                accuracy=1.0
            )
            actual_distance = service.calculate_distance(user_location, poi_location)
            
            # Mock database queries
            with patch('app.models.walking_session.WalkingSession.query') as mock_session_query:
                mock_session_query.get.return_value = session
                
                with patch('app.models.poi.POI.query') as mock_poi_query:
                    mock_poi_query.all.return_value = [poi]
                    
                    # Act: Check for nearby POIs
                    detected_poi = service.check_nearby_pois(user_location, session.id)
            
            # Assert: Detection should match distance threshold
            if actual_distance <= POIDetectorService.DETECTION_RANGE_METERS:
                assert detected_poi is not None, \
                    f"POI at {actual_distance:.2f}m should be detected (threshold: {POIDetectorService.DETECTION_RANGE_METERS}m)"
                assert detected_poi.id == poi.id, \
                    "Detected POI should be the correct POI"
            else:
                assert detected_poi is None, \
                    f"POI at {actual_distance:.2f}m should NOT be detected (threshold: {POIDetectorService.DETECTION_RANGE_METERS}m)"
    
    @settings(max_examples=100, suppress_health_check=[HealthCheck.function_scoped_fixture])
    @given(
        user_location=locations(),
        num_pois=st.integers(min_value=1, max_value=10)
    )
    def test_property_9_poi_detection_with_multiple_pois(self, app, user_location, num_pois):
        """
        Feature: buyu-walking-app, Property 9: 景点检测触发
        Validates: Requirements 2.2
        
        Property: For any user location and multiple POIs, the system should
        detect the first unvisited POI within detection range.
        
        This tests that detection works correctly with multiple POIs.
        """
        with app.app_context():
            # Arrange: Create session and multiple POIs
            session = WalkingSession()
            session.id = 'test_session_id'
            session.user_id = 'test_user_id'
            session.status = 'active'
            session.visited_poi_ids = []
            
            # Generate POIs at various distances
            pois = []
            service = POIDetectorService()
            
            for i in range(num_pois):
                poi = Mock(spec=POI)
                poi.id = f'poi_{i}'
                poi.name = f'景点_{i}'
                # Place POIs at different distances
                # Some within range, some outside
                distance_factor = (i + 1) * 10  # 10m, 20m, 30m, 40m, etc.
                # Approximate: 0.0001 degrees ≈ 11 meters
                offset = distance_factor / 111000  # Convert meters to degrees
                
                # Ensure we don't exceed valid latitude bounds
                new_lat = user_location.latitude + offset
                if new_lat > 90:
                    new_lat = user_location.latitude - offset
                new_lat = max(-90, min(90, new_lat))
                
                poi.latitude = new_lat
                poi.longitude = user_location.longitude
                poi.category = '风景名胜'
                pois.append(poi)
            
            # Mock database queries
            with patch('app.models.walking_session.WalkingSession.query') as mock_session_query:
                mock_session_query.get.return_value = session
                
                with patch('app.models.poi.POI.query') as mock_poi_query:
                    mock_poi_query.all.return_value = pois
                    
                    # Act: Check for nearby POIs
                    detected_poi = service.check_nearby_pois(user_location, session.id)
            
            # Assert: Should detect a POI if any are within range
            # Calculate which POIs are within range
            pois_within_range = []
            for poi in pois:
                poi_location = Location(
                    latitude=poi.latitude,
                    longitude=poi.longitude,
                    accuracy=1.0
                )
                distance = service.calculate_distance(user_location, poi_location)
                if distance <= POIDetectorService.DETECTION_RANGE_METERS:
                    pois_within_range.append(poi)
            
            if len(pois_within_range) > 0:
                assert detected_poi is not None, \
                    f"Should detect a POI when {len(pois_within_range)} POIs are within range"
                assert detected_poi in pois, \
                    "Detected POI should be from the POI list"
            else:
                assert detected_poi is None, \
                    "Should not detect any POI when none are within range"


class TestPOIDeduplicationProperty:
    """Property-based tests for POI deduplication (idempotence)."""
    
    @settings(max_examples=100, suppress_health_check=[HealthCheck.function_scoped_fixture])
    @given(
        user_location=locations(),
        poi=poi_with_location(),
        num_visits=st.integers(min_value=2, max_value=10)
    )
    def test_property_12_poi_deduplication_idempotence(self, app, user_location, poi, num_visits):
        """
        Feature: buyu-walking-app, Property 12: 景点去重
        Validates: Requirements 2.5
        
        Property: For any walking session, the same POI should only be introduced
        once, even if the user enters the detection range multiple times.
        
        This tests the idempotence property: detecting the same POI multiple times
        should not trigger multiple introductions.
        """
        with app.app_context():
            # Arrange: Create a session
            session = WalkingSession()
            session.id = 'test_session_id'
            session.user_id = 'test_user_id'
            session.status = 'active'
            session.visited_poi_ids = []
            
            service = POIDetectorService()
            
            # Ensure POI is within detection range
            # Place POI very close to user location (within 10 meters)
            # Ensure we don't exceed valid latitude bounds
            new_lat = user_location.latitude + 0.00005  # ~5.5 meters
            if new_lat > 90:
                new_lat = user_location.latitude - 0.00005
            poi.latitude = max(-90, min(90, new_lat))
            poi.longitude = user_location.longitude
            
            # Mock database queries
            with patch('app.models.walking_session.WalkingSession.query') as mock_session_query:
                mock_session_query.get.return_value = session
                
                with patch('app.models.poi.POI.query') as mock_poi_query:
                    mock_poi_query.all.return_value = [poi]
                    
                    with patch('app.services.poi_detector_service.db.session') as mock_db_session:
                        mock_db_session.commit = Mock()
                        
                        # Act: Check for nearby POIs multiple times
                        detection_results = []
                        for i in range(num_visits):
                            detected_poi = service.check_nearby_pois(user_location, session.id)
                            detection_results.append(detected_poi)
                            
                            # If POI was detected, mark it as visited
                            if detected_poi is not None:
                                service.mark_poi_visited(session.id, detected_poi.id)
            
            # Assert: POI should only be detected on first visit
            # First detection should find the POI
            assert detection_results[0] is not None, \
                "First detection should find the POI within range"
            assert detection_results[0].id == poi.id, \
                "First detection should return the correct POI"
            
            # Subsequent detections should return None (POI already visited)
            for i in range(1, num_visits):
                assert detection_results[i] is None, \
                    f"Detection {i+1} should return None (POI already visited)"
            
            # Verify POI was marked as visited
            assert poi.id in session.visited_poi_ids, \
                "POI should be in visited list"
            
            # Verify POI appears only once in visited list (deduplication)
            assert session.visited_poi_ids.count(poi.id) == 1, \
                "POI should appear exactly once in visited list"
    
    @settings(max_examples=100, suppress_health_check=[HealthCheck.function_scoped_fixture])
    @given(
        user_location=locations(),
        num_pois=st.integers(min_value=2, max_value=5)
    )
    def test_property_12_multiple_pois_deduplication(self, app, user_location, num_pois):
        """
        Feature: buyu-walking-app, Property 12: 景点去重
        Validates: Requirements 2.5
        
        Property: For any walking session with multiple POIs, each POI should
        only be introduced once, regardless of how many times the user enters
        each POI's detection range.
        
        This tests deduplication across multiple POIs.
        """
        with app.app_context():
            # Arrange: Create session and multiple POIs
            session = WalkingSession()
            session.id = 'test_session_id'
            session.user_id = 'test_user_id'
            session.status = 'active'
            session.visited_poi_ids = []
            
            service = POIDetectorService()
            
            # Create POIs all within detection range
            pois = []
            for i in range(num_pois):
                poi = Mock(spec=POI)
                poi.id = f'poi_{i}'
                poi.name = f'景点_{i}'
                # Place all POIs very close to user (within 10 meters)
                # Ensure we don't exceed valid latitude bounds
                new_lat = user_location.latitude + (i * 0.00003)  # ~3.3m apart
                if new_lat > 90:
                    new_lat = user_location.latitude - (i * 0.00003)
                poi.latitude = max(-90, min(90, new_lat))
                poi.longitude = user_location.longitude
                poi.category = '风景名胜'
                pois.append(poi)
            
            # Mock database queries
            with patch('app.models.walking_session.WalkingSession.query') as mock_session_query:
                mock_session_query.get.return_value = session
                
                with patch('app.models.poi.POI.query') as mock_poi_query:
                    mock_poi_query.all.return_value = pois
                    
                    with patch('app.services.poi_detector_service.db.session') as mock_db_session:
                        mock_db_session.commit = Mock()
                        
                        # Act: Simulate multiple detection cycles
                        # Each cycle should detect one POI and mark it visited
                        detected_pois = []
                        for cycle in range(num_pois * 2):  # More cycles than POIs
                            detected_poi = service.check_nearby_pois(user_location, session.id)
                            if detected_poi is not None:
                                detected_pois.append(detected_poi)
                                service.mark_poi_visited(session.id, detected_poi.id)
            
            # Assert: Each POI should be detected at most once
            detected_poi_ids = [poi.id for poi in detected_pois]
            unique_detected_ids = set(detected_poi_ids)
            
            assert len(detected_poi_ids) == len(unique_detected_ids), \
                "Each POI should be detected at most once (no duplicates)"
            
            assert len(detected_pois) <= num_pois, \
                f"Should detect at most {num_pois} POIs"
            
            # Verify visited list has no duplicates
            assert len(session.visited_poi_ids) == len(set(session.visited_poi_ids)), \
                "Visited POI list should have no duplicates"
    
    @settings(max_examples=100, suppress_health_check=[HealthCheck.function_scoped_fixture])
    @given(
        user_location=locations(),
        poi=poi_with_location()
    )
    def test_property_12_mark_poi_visited_idempotence(self, app, user_location, poi):
        """
        Feature: buyu-walking-app, Property 12: 景点去重
        Validates: Requirements 2.5
        
        Property: Marking a POI as visited multiple times should be idempotent -
        the POI should appear only once in the visited list.
        
        This tests the mark_poi_visited method's idempotence.
        """
        with app.app_context():
            # Arrange: Create a session
            session = WalkingSession()
            session.id = 'test_session_id'
            session.user_id = 'test_user_id'
            session.status = 'active'
            session.visited_poi_ids = []
            
            service = POIDetectorService()
            
            # Mock database queries
            with patch('app.models.walking_session.WalkingSession.query') as mock_session_query:
                mock_session_query.get.return_value = session
                
                with patch('app.services.poi_detector_service.db.session') as mock_db_session:
                    mock_db_session.commit = Mock()
                    
                    # Act: Mark the same POI as visited multiple times
                    num_marks = 5
                    for _ in range(num_marks):
                        service.mark_poi_visited(session.id, poi.id)
            
            # Assert: POI should appear only once in visited list
            assert poi.id in session.visited_poi_ids, \
                "POI should be in visited list"
            
            assert session.visited_poi_ids.count(poi.id) == 1, \
                f"POI should appear exactly once in visited list, not {session.visited_poi_ids.count(poi.id)} times"
            
            assert len(session.visited_poi_ids) == 1, \
                "Visited list should contain exactly one POI"
